fix(cli): stop the hard-coded heap cap from overriding the operator - #3368
Open
ntdatt812 wants to merge 1 commit into
Open
fix(cli): stop the hard-coded heap cap from overriding the operator#3368ntdatt812 wants to merge 1 commit into
ntdatt812 wants to merge 1 commit into
Conversation
The next-server child is spawned with --max-old-space-size=6144 on the command line. Node reads NODE_OPTIONS first and lets command-line flags win, so that value cannot be lowered from outside: on a host with a cgroup limit (systemd MemoryMax, docker --memory, k8s) the child runs believing it has 6 GB of heap. GC never feels the limit, RSS climbs to the ceiling, and the kernel OOM-kills next-server — taking in-flight streaming responses with it. The supervisor restarts and it happens again. The default is unchanged for the desktop case it was raised for. It now steps aside once the operator has said what they want: - NINEROUTER_MAX_OLD_SPACE_SIZE=384 caps the heap at 384 MB; 0 passes no flag at all and lets node size the heap from the memory it can see. - A --max-old-space-size already present in NODE_OPTIONS is respected rather than overridden, so the standard mechanism finally works. A junk value keeps the default and warns, rather than quietly leaving the heap uncapped — losing a safety cap to a typo is the wrong failure direction. Deriving the default from available memory is deliberately not done here: os.totalmem() reports the host's RAM, not the cgroup limit, so it would not help the containerised case that motivates this and would change behaviour everywhere else. Reported in decolua#3365, and previously in decolua#1982.
afandiaziz
added a commit
to afandiaziz/9router
that referenced
this pull request
Aug 20, 2026
afandiaziz
added a commit
to afandiaziz/9router
that referenced
this pull request
Aug 20, 2026
…/security/providers Verified via trial-merge + per-PR tests (84 pass/0 fail), OAuth baseline identical, providers baseline additive-only (+reasonix/ovh/joycode/openmodel). PRs: decolua#3411 decolua#3370 decolua#3369 decolua#3368 decolua#3393 decolua#3366 decolua#3395 decolua#3382 decolua#3359 decolua#3408 decolua#3357 decolua#3379 decolua#3380 decolua#3381 decolua#3396 decolua#3338 Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3365. Also the ask in #1982.
cli/cli.js:615spawns the next-server child with a fixed heap cap:Why that cannot be worked around from outside
Node reads
NODE_OPTIONSbefore command-line flags and lets the command line win. So an operator who sets a lower cap gets it silently discarded. Measured, not assumed:That is the whole bug. On a host with a cgroup limit — systemd
MemoryMax,docker --memory, k8s — the child runs believing it has 6 GB. GC thresholds sit near 6 GB, so a 485 MB heap looks like 8% utilisation and collection never becomes urgent; RSS climbs to the cgroup ceiling and the kernel OOM-killsnext-serverinstead. In-flight streaming responses die with the process, the supervisor restarts it, and the cycle repeats — which matches the reporter's 64 restart segments all peaking in the same 478–485 MB band.What changes
The default is unchanged at 6144 for the desktop case it was raised for. It just stops overriding the operator:
--max-old-space-size=6144NINEROUTER_MAX_OLD_SPACE_SIZE=384--max-old-space-size=384NINEROUTER_MAX_OLD_SPACE_SIZE=0NODE_OPTIONS=--max-old-space-size=384NINEROUTER_MAX_OLD_SPACE_SIZE=abc--max-old-space-size=6144+ a warningBoth mechanisms are covered because they serve different users:
NODE_OPTIONSis what a systemd unit ordocker run -ealready uses, while the dedicated variable is reachable for someone running the tray build ornpx 9routerwho has no convenient place to set node flags.A junk value keeps the default and warns rather than silently leaving the heap uncapped — losing a safety cap to a typo is the wrong direction to fail in.
Verified end to end against a real child process, not just the resolver:
The resolver lives in
cli/hooks/nodeFlags.jsnext tosqliteRuntime.js/trayRuntime.js, which is both the existing convention for CLI runtime helpers and what makes it testable —cli.jsitself runs on require.hooks/is already in the package'sfiles, so it ships.What I did not do
The issue's first suggestion is to derive the default from available RAM. I left it out on purpose:
os.totalmem()reports the host's memory, not the cgroup limit, so underdocker --memoryor a k8s limit it would still report the wrong number — it would not help the case that motivates this report, while changing behaviour for every existing install. Reading/sys/fs/cgroup/memory.maxwould work but is Linux-only and I have no containerised host here to verify it on. Worth doing as a follow-up, with the caveat that an explicit setting must still win.The third suggestion — auditing where request/response bodies are retained (#1982, #2472) — is a separate piece of work. As the reporter says, a correct cap lets GC reclaim; it does not remove whatever is retaining.
Verification
8 tests in
unit/cli-heap-flags-3365.test.js: the 6144 default is preserved when nothing is set; an explicit cap is honoured (including surrounding whitespace);0emits no flag; a--max-old-space-sizeinNODE_OPTIONSsuppresses ours, including the underscore spelling node also accepts and when mixed with other flags; an unrelatedNODE_OPTIONSdoes not; a lookalike flag (--max-old-space-size-hint) does not false-positive; the dedicated variable beatsNODE_OPTIONS; junk falls back to the default and warns; an empty/whitespace value counts as unset and warns about nothing.Mutation-checked — deleting the
NODE_OPTIONSpassthrough fails exactly the "stands aside when NODE_OPTIONS already caps the heap" test.node --check cli/cli.jspasses.npx eslintreports no issues on the changed files.Full suite (
npx vitest run unit translator): 1808 passed / 95 failed. Two runs differed from master's failing set only in the network-dependentunit/xai-oauth-service.test.jstimeouts (and, in one run, two known order-dependent DB tests) — these flip between runs on an unmodified tree too. Nothing undertests/importscli/except the new test, so this change cannot reach them.